Skip to content

fix: normalize MSYS/Unix paths on Windows to prevent phantom shadow branches#845

Open
gtrrz-victor wants to merge 3 commits intomainfrom
gtrrz-victor/fix-windows-test
Open

fix: normalize MSYS/Unix paths on Windows to prevent phantom shadow branches#845
gtrrz-victor wants to merge 3 commits intomainfrom
gtrrz-victor/fix-windows-test

Conversation

@gtrrz-victor
Copy link
Copy Markdown
Contributor

@gtrrz-victor gtrrz-victor commented Apr 3, 2026

Summary

  • On Windows, Claude Code reports file paths in Unix formats that filepath.IsAbs doesn't recognize (/c/Users/..., /tmp/..., /home/user/...). These leaked through FilterAndNormalizePaths into filterToUncommittedFiles, causing git diff to fail with "fatal: Invalid path" or "outside repository" errors. The fail-open behavior then kept the files as "uncommitted", creating a phantom shadow branch.
  • Add NormalizeMSYSPath to convert MSYS drive paths (/c/C:/) and virtual dirs (/tmp/ → Windows temp dir), plus a catch-all that drops any remaining Unix-style paths the OS can't resolve (e.g., /home/user/... from agent sandboxes).
  • Switch filterToUncommittedFiles from go-git content comparison to git diff CLI, which handles autocrlf natively and captures stderr for better diagnostics.

Root cause

Transcript-extracted paths on Windows arrive in three Unix formats:

Format Source Example
/c/Users/... MSYS2/Git Bash Git hooks run through MSYS2 bash
/tmp/... MSYS2 virtual dir Maps to %TEMP%
/home/user/... Agent sandbox/container Claude Code runs in a container

filepath.IsAbs("/c/Users/...") returns false on Windows, so ToRelativePath passed these through unchanged. They reached git diff --name-only HEAD -- /home/user/docs/red.md which failed with exit 128. The fail-open behavior kept them as "uncommitted" files → stop hook created a shadow branch → WaitForNoShadowBranches assertion failed.

Test plan

  • mise run fmt && mise run lint — clean
  • mise run test — all unit tests pass
  • Windows E2E TestSingleSessionSubagentCommitInTurn10/10 passes on GitHub Actions

🤖 Generated with Claude Code


Note

Medium Risk
Moderate risk: changes core path normalization and the logic that determines which modified files are considered uncommitted, and now shells out to git diff, which could behave differently across environments if git isn’t available or returns unexpected output.

Overview
Fixes Windows path handling by normalizing MSYS/Git-Bash style paths (e.g., /c/..., /tmp/...) before converting to repo-relative paths, and dropping remaining Unix-style paths that the OS can’t treat as absolute.

Reworks filterToUncommittedFiles to use git diff --name-only HEAD -- <files> instead of go-git content comparisons, improving Windows reliability (avoiding index lock contention and respecting autocrlf) and adding stderr-backed warning logs when git diff fails (still fail-open).

Written by Cursor Bugbot for commit e45520b. Configure here.

@gtrrz-victor gtrrz-victor requested a review from a team as a code owner April 3, 2026 15:10
Copilot AI review requested due to automatic review settings April 3, 2026 15:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses Windows-specific path normalization issues that caused transcript-derived paths to break git diff and incorrectly keep files marked as “uncommitted,” creating phantom shadow branches.

Changes:

  • Normalize MSYS/Git-Bash-style paths (e.g., /c/..., /tmp/...) during absolute→relative conversion and drop unresolvable Unix-style paths on Windows.
  • Rework filterToUncommittedFiles to use git diff --name-only (CLI) instead of go-git content comparisons to improve Windows behavior (locks/autocrlf) and diagnostics.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
cmd/entire/cli/state.go Replaces go-git HEAD/working-tree content checks with a git diff-based filter for “already committed” files.
cmd/entire/cli/paths/paths.go Adds MSYS path normalization and filters out Unix-style absolute paths that can’t be resolved/converted on Windows.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: git diff drops untracked files breaking documented contract
    • I updated filterToUncommittedFiles to union git diff --name-only HEAD with git ls-files --others -- so files not in HEAD are preserved as uncommitted.
  • ✅ Fixed: NormalizeMSYSPath lacks platform guard despite docstring claim
    • I added an early runtime.GOOS != "windows" return in NormalizeMSYSPath so MSYS rewrites only run on Windows as documented.

Create PR

Or push these changes by commenting:

@cursor push 3471f3ed42
Preview (3471f3ed42)
diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go
--- a/cmd/entire/cli/paths/paths.go
+++ b/cmd/entire/cli/paths/paths.go
@@ -7,6 +7,7 @@
 	"os/exec"
 	"path/filepath"
 	"regexp"
+	"runtime"
 	"strings"
 	"sync"
 
@@ -155,6 +156,15 @@
 // ToRelativePath converts an absolute path to relative.
 // Returns empty string if the path is outside the working directory.
 func ToRelativePath(absPath, cwd string) string {
+	absPath = NormalizeMSYSPath(absPath)
+
+	// After MSYS normalization, a path starting with "/" that the OS still
+	// doesn't recognize as absolute is an unconvertible Unix path (e.g.,
+	// /home/user/... from a container/sandbox on Windows). Filter it out.
+	if strings.HasPrefix(absPath, "/") && !filepath.IsAbs(absPath) {
+		return ""
+	}
+
 	if !filepath.IsAbs(absPath) {
 		return absPath
 	}
@@ -166,6 +176,34 @@
 	return relPath
 }
 
+// msysDrivePrefix matches MSYS/Git-Bash-style absolute paths like /c/ or /D/.
+// Git for Windows executes hooks through MSYS2 bash, which converts Windows paths
+// (C:\Users\...) to Unix-style (/c/Users/...) in tool output and transcripts.
+var msysDrivePrefix = regexp.MustCompile(`^/([a-zA-Z])/`)
+
+// NormalizeMSYSPath converts MSYS/Git-Bash paths to Windows paths.
+// Handles two MSYS conventions:
+//   - Drive paths: /c/Users/... → C:/Users/...
+//   - Virtual dirs: /tmp/... → <TEMP>/... (MSYS2 maps /tmp to the Windows temp dir)
+//
+// Returns the input unchanged on non-Windows or if the path doesn't match.
+func NormalizeMSYSPath(p string) string {
+	if runtime.GOOS != "windows" {
+		return p
+	}
+
+	if m := msysDrivePrefix.FindStringSubmatch(p); m != nil {
+		return strings.ToUpper(m[1]) + ":/" + p[3:]
+	}
+	// MSYS2 maps /tmp to the Windows temp directory.
+	if strings.HasPrefix(p, "/tmp/") {
+		if tmp := os.TempDir(); tmp != "" {
+			return filepath.Join(tmp, p[5:])
+		}
+	}
+	return p
+}
+
 // nonAlphanumericRegex matches any non-alphanumeric character
 var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`)
 

diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go
--- a/cmd/entire/cli/paths/paths_test.go
+++ b/cmd/entire/cli/paths/paths_test.go
@@ -3,6 +3,7 @@
 import (
 	"os"
 	"path/filepath"
+	"runtime"
 	"testing"
 )
 
@@ -121,3 +122,27 @@
 		t.Errorf("GetClaudeProjectDir() = %q, want %q", result, expected)
 	}
 }
+
+func TestNormalizeMSYSPath_NonWindows_NoRewrite(t *testing.T) {
+	t.Parallel()
+
+	if runtime.GOOS == "windows" {
+		t.Skip("non-Windows behavior test")
+	}
+
+	tests := []string{
+		"/c/Users/test/repo/file.txt",
+		"/D/work/project/main.go",
+		"/tmp/repo/file.txt",
+		"/tmp",
+	}
+
+	for _, input := range tests {
+		t.Run(input, func(t *testing.T) {
+			got := NormalizeMSYSPath(input)
+			if got != input {
+				t.Errorf("NormalizeMSYSPath(%q) = %q, want unchanged %q", input, got, input)
+			}
+		})
+	}
+}

diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go
--- a/cmd/entire/cli/state.go
+++ b/cmd/entire/cli/state.go
@@ -7,6 +7,7 @@
 	"fmt"
 	"log/slog"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 	"time"
@@ -288,64 +289,89 @@
 // filterToUncommittedFiles removes files from the list that are already committed to HEAD
 // with matching content. This prevents re-adding files that an agent committed mid-turn
 // (already condensed by PostCommit) back to FilesTouched via SaveStep. Files not in
-// HEAD or with different content in the working tree are kept. Fails open: if any git
-// operation errors, returns the original list unchanged.
+// HEAD or with different content in the working tree are kept. Fails open: if git
+// errors (e.g. bare repo, no HEAD), returns the original list unchanged.
+//
+// Uses the git CLI instead of go-git to avoid index lock contention on Windows and
+// to ensure consistent autocrlf/eol handling (git CLI respects system-level config,
+// while go-git's content comparison would need manual CRLF normalization).
 func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string {
 	if len(files) == 0 {
 		return files
 	}
 
-	repo, err := openRepository(ctx)
+	// git diff --name-only HEAD -- <files> prints files that differ from HEAD.
+	// Empty output means tracked files match HEAD, but it does NOT include untracked files.
+	// We handle untracked files separately via git ls-files --others.
+	args := append([]string{"diff", "--name-only", "HEAD", "--"}, files...)
+	cmd := exec.CommandContext(ctx, "git", args...)
+	cmd.Dir = repoRoot
+	out, err := cmd.Output()
 	if err != nil {
-		return files // fail open
+		// Capture stderr for diagnostics (exec.ExitError carries it).
+		var stderr string
+		var exitErr *exec.ExitError
+		if errors.As(err, &exitErr) {
+			stderr = string(exitErr.Stderr)
+		}
+		// Fail open: no HEAD (empty repo, initial commit), not a git repo, etc.
+		logging.Warn(ctx, "filterToUncommittedFiles: git diff failed, keeping all files as uncommitted",
+			slog.String("error", err.Error()),
+			slog.String("stderr", stderr),
+			slog.String("dir", repoRoot),
+			slog.Any("files", files))
+		return files
 	}
 
-	head, err := repo.Head()
-	if err != nil {
-		return files // fail open (empty repo, detached HEAD, etc.)
+	// git ls-files --others -- <files> prints untracked files (including ignored ones).
+	// This preserves the contract that files not in HEAD are kept as uncommitted.
+	untrackedArgs := append([]string{"ls-files", "--others", "--"}, files...)
+	untrackedCmd := exec.CommandContext(ctx, "git", untrackedArgs...)
+	untrackedCmd.Dir = repoRoot
+	untrackedOut, untrackedErr := untrackedCmd.Output()
+	if untrackedErr != nil {
+		var stderr string
+		var exitErr *exec.ExitError
+		if errors.As(untrackedErr, &exitErr) {
+			stderr = string(exitErr.Stderr)
+		}
+		logging.Warn(ctx, "filterToUncommittedFiles: git ls-files failed, keeping all files as uncommitted",
+			slog.String("error", untrackedErr.Error()),
+			slog.String("stderr", stderr),
+			slog.String("dir", repoRoot),
+			slog.Any("files", files))
+		return files
 	}
 
-	commit, err := repo.CommitObject(head.Hash())
-	if err != nil {
-		return files // fail open
+	trimmed := strings.TrimRight(string(out), "\n")
+	trimmedUntracked := strings.TrimRight(string(untrackedOut), "\n")
+	if trimmed == "" && trimmedUntracked == "" {
+		return nil // all files are committed with matching content
 	}
 
-	headTree, err := commit.Tree()
-	if err != nil {
-		return files // fail open
+	// Build sets of files that are uncommitted:
+	// - files differing from HEAD
+	// - files not tracked in HEAD (currently untracked)
+	diffSet := make(map[string]bool)
+	for _, line := range strings.Split(trimmed, "\n") {
+		if line != "" {
+			diffSet[filepath.ToSlash(line)] = true
+		}
 	}
+	untrackedSet := make(map[string]bool)
+	for _, line := range strings.Split(trimmedUntracked, "\n") {
+		if line != "" {
+			untrackedSet[filepath.ToSlash(line)] = true
+		}
+	}
 
 	var result []string
-	for _, relPath := range files {
-		headFile, err := headTree.File(relPath)
-		if err != nil {
-			// File not in HEAD — it's uncommitted
-			result = append(result, relPath)
-			continue
+	for _, f := range files {
+		path := filepath.ToSlash(f)
+		if diffSet[path] || untrackedSet[path] {
+			result = append(result, f)
 		}
-
-		// File is in HEAD — compare content with working tree
-		absPath := filepath.Join(repoRoot, relPath)
-		workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source
-		if err != nil {
-			// Can't read working tree file (deleted?) — keep it
-			result = append(result, relPath)
-			continue
-		}
-
-		headContent, err := headFile.Contents()
-		if err != nil {
-			result = append(result, relPath)
-			continue
-		}
-
-		if string(workingContent) != headContent {
-			// Working tree differs from HEAD — uncommitted changes
-			result = append(result, relPath)
-		}
-		// else: content matches HEAD — already committed, skip
 	}
-
 	return result
 }
 

diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go
--- a/cmd/entire/cli/state_test.go
+++ b/cmd/entire/cli/state_test.go
@@ -9,6 +9,7 @@
 
 	"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
 	"github.com/entireio/cli/cmd/entire/cli/paths"
+	"github.com/entireio/cli/cmd/entire/cli/testutil"
 	"github.com/go-git/go-git/v6"
 	"github.com/go-git/go-git/v6/plumbing/object"
 	"github.com/stretchr/testify/require"
@@ -796,3 +797,55 @@
 		})
 	}
 }
+
+func TestFilterToUncommittedFiles_KeepsUntrackedFiles(t *testing.T) {
+	t.Parallel()
+
+	tmpDir := t.TempDir()
+	testutil.InitRepo(t, tmpDir)
+	testutil.WriteFile(t, tmpDir, "tracked.txt", "tracked content")
+	testutil.GitAdd(t, tmpDir, "tracked.txt")
+	testutil.GitCommit(t, tmpDir, "initial commit")
+
+	testutil.WriteFile(t, tmpDir, "untracked.txt", "new file content")
+
+	files := []string{"tracked.txt", "untracked.txt"}
+	got := filterToUncommittedFiles(context.Background(), files, tmpDir)
+
+	want := []string{"untracked.txt"}
+	if len(got) != len(want) {
+		t.Fatalf("filterToUncommittedFiles() = %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Errorf("filterToUncommittedFiles()[%d] = %q, want %q", i, got[i], want[i])
+		}
+	}
+}
+
+func TestFilterToUncommittedFiles_KeepsTrackedDiffsAndUntracked(t *testing.T) {
+	t.Parallel()
+
+	tmpDir := t.TempDir()
+	testutil.InitRepo(t, tmpDir)
+	testutil.WriteFile(t, tmpDir, "tracked.txt", "tracked content")
+	testutil.GitAdd(t, tmpDir, "tracked.txt")
+	testutil.GitCommit(t, tmpDir, "initial commit")
+
+	// tracked.txt now differs from HEAD, and untracked.txt is not in HEAD.
+	testutil.WriteFile(t, tmpDir, "tracked.txt", "updated tracked content")
+	testutil.WriteFile(t, tmpDir, "untracked.txt", "new file content")
+
+	files := []string{"tracked.txt", "untracked.txt"}
+	got := filterToUncommittedFiles(context.Background(), files, tmpDir)
+
+	want := []string{"tracked.txt", "untracked.txt"}
+	if len(got) != len(want) {
+		t.Fatalf("filterToUncommittedFiles() = %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Errorf("filterToUncommittedFiles()[%d] = %q, want %q", i, got[i], want[i])
+		}
+	}
+}

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

@gtrrz-victor gtrrz-victor force-pushed the gtrrz-victor/fix-windows-test branch from e45520b to d98aaea Compare April 3, 2026 15:27
…ranches

On Windows, transcript-extracted file paths arrive in Unix formats that
filepath.IsAbs doesn't recognize (/c/Users/..., /tmp/..., /home/user/...).
These leaked through FilterAndNormalizePaths into filterToUncommittedFiles,
causing phantom shadow branches that failed test assertions.

Add NormalizeMSYSPath to convert known MSYS paths (/c/ → C:/, /tmp/ →
temp dir), and drop any remaining Unix-style paths the OS can't resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@gtrrz-victor gtrrz-victor force-pushed the gtrrz-victor/fix-windows-test branch from d98aaea to 1af6121 Compare April 3, 2026 15:59
gtrrz-victor and others added 2 commits April 3, 2026 18:10
…ndling

Cover MSYS drive conversion (/c/ → C:/), /tmp/ mapping, passthrough
of already-relative and non-matching paths, and edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 94ff42cf7576
…d tags

NormalizeMSYSPath had no runtime.GOOS guard, corrupting valid Unix
paths on macOS/Linux (e.g., /tmp/repo/file.txt → /var/folders/...).

Move ToRelativePath and NormalizeMSYSPath into relative_windows.go
(with MSYS normalization + Unix path filter) and relative_unix.go
(plain filepath.Rel, no normalization). Tests split accordingly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: cbe58c84ed5b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants